Next.js Dynamic Routes 業務実装パターン | 実務的な設計と応用例
Next.jsのDynamic Routesは、ECサイトの商品ページ、ブログ記事、ユーザープロフィールなど、動的に生成されるページを効率的に管理する機能です。本記事では、教科書的な説明ではなく、実際の業務で使用される実装パターンを詳しく解説します。
1. Dynamic Routesの基本概念
Dynamic Routesとは、ファイルシステムのルーティングを使用して、URLのパラメータに基づいた動的なページを生成する仕組みです。Next.jsではブラケット記法を使ってファイル名を定義します。
例えば、/products/123というURLに対応させるには、pages/products/[id].tsxというファイルを作成します。
この機能の最大の利点は、数百万個のページを事前に生成する必要がなく、必要に応じてオンデマンドで生成できる点です。これにより、ビルド時間の短縮とサーバーリソースの効率化が実現できます。
2. 業務でのユースケース
2.1 ECサイトの商品ページ管理
最も一般的なユースケースは、ECサイトの商品ページです。SKUが数万個ある場合、全ページを事前ビルドするのは現実的ではありません。Dynamic Routesを使えば、ユーザーが訪問した商品ページのみ生成されます。
2.2 ブログやコンテンツ管理
記事数が常に増え続けるブログシステムでは、Dynamic Routesが必須です。/blog/[slug]の形式で、スラッグから記事データを取得し、動的にレンダリングします。
2.3 ユーザープロフィールページ
SaaSアプリケーションでは、/users/[userId]のようなプライベートページをDynamic Routesで管理します。認証とデータベースクエリを組み合わせることで、セキュアなユーザー専用ページが実現できます。
3. 実装コード:実務的なパターン
3.1 基本的なDynamic Routeの実装
// pages/products/[id].tsx
import { GetStaticProps, GetStaticPaths } from 'next';
import { useRouter } from 'next/router';
interface Product {
id: string;
name: string;
price: number;
description: string;
imageUrl: string;
stock: number;
}
interface Props {
product: Product;
fallback: boolean;
}
export default function ProductPage({ product, fallback }: Props) {
const router = useRouter();
if (router.isFallback) {
return (
ページを読み込み中...
);
}
if (!product) {
return (
商品が見つかりません
);
}
return (
{product.name}
¥{product.price.toLocaleString('ja-JP')}
{product.description}
在庫: 0 ? 'text-green-600' : 'text-red-600'}>
{product.stock > 0 ? `${product.stock}個` : '品切れ'}
);
}
export const getStaticPaths: GetStaticPaths = async () => {
// ビルド時に人気商品のIDを事前生成
const popularProducts = await fetchPopularProductIds();
const paths = popularProducts.map((id) => ({
params: { id: id.toString() },
}));
return {
paths,
fallback: 'blocking', // オンデマンド生成、ユーザーを待たせない
};
};
export const getStaticProps: GetStaticProps = async ({ params }) => {
try {
const id = params?.id as string;
const product = await fetchProductFromDatabase(id);
if (!product) {
return {
notFound: true,
revalidate: 60, // 1分後に再検証
};
}
return {
props: { product, fallback: false },
revalidate: 3600, // 1時間ごとに再検証(ISR)
};
} catch (error) {
console.error('Error fetching product:', error);
return {
revalidate: 60, // エラー時は短い間隔で再試行
};
}
};
// ダミー実装例
async function fetchProductFromDatabase(id: string): Promise {
const res = await fetch(`https://api.example.com/products/${id}`);
if (!res.ok) return null;
return res.json();
}
async function fetchPopularProductIds(): Promise {
const res = await fetch('https://api.example.com/products/popular?limit=100');
const data = await res.json();
return data.map((p: any) => p.id);
}
3.2 複数のパラメータを持つ実装
// pages/blog/[year]/[month]/[slug].tsx
import { GetStaticProps, GetStaticPaths } from 'next';
interface BlogPost {
title: string;
content: string;
author: string;
publishedAt: string;
tags: string[];
}
interface Props {
post: BlogPost;
relatedPosts: BlogPost[];
}
export default function BlogPostPage({ post, relatedPosts }: Props) {
return (
{post.title}
著者: {post.author}
|
{new Date(post.publishedAt).toLocaleDateString('ja-JP')}
関連記事
{relatedPosts.map((p) => (
-
{p.title}
))}
);
}
export const getStaticPaths: GetStaticPaths = async () => {
// 最新100件のブログ記事のパスを生成
const posts = await fetchLatestBlogPosts(100);
const paths = posts.map((post) => ({
params: {
year: post.publishedAt.split('-')[0],
month: post.publishedAt.split('-')[1],
slug: post.slug,
},
}));
return {
paths,
fallback: 'blocking',
};
};
export const getStaticProps: GetStaticProps = async ({ params }) => {
const { year, month, slug } = params as Record;
try {
const post = await fetchBlogPost(year, month, slug);
if (!post) {
return { notFound: true };
}
const relatedPosts = await fetchRelatedPosts(post.tags, slug);
return {
props: { post, relatedPosts },
revalidate: 86400, // 24時間ごとに再検証
};
} catch (error) {
console.error('Error fetching blog post:', error);
return {
revalidate: 300, // エラー時は5分後に再試行
};
}
};
async function fetchLatestBlogPosts(limit: number) {
// 実装例
return [];
}
async function fetchBlogPost(year: string, month: string, slug: string): Promise {
// 実装例
return null;
}
async function fetchRelatedPosts(tags: string[], currentSlug: string): Promise {
// 実装例
return [];
}
3.3 サーバーサイドレンダリングを使用したパターン
// pages/admin/users/[userId].tsx
import { GetServerSideProps } from 'next';
import { useSession } from 'next-auth/react';
import { useRouter } from 'next/router';
interface User {
id: string;
email: string;
name: string;
role: string;
createdAt: string;
lastLogin: string;
}
interface Props {
user: User;
}
export default function AdminUserPage({ user }: Props) {
const { data: session } = useSession();
const router = useRouter();
// 認証チェック
if (!session || session.user.role !== 'admin') {
return アクセス権がありません;
}
return (
ユーザー詳細
{user.id}
{user.email}
{user.name}
{user.role}
{new Date(user.createdAt).toLocaleDateString('ja-JP')}
{new Date(user.lastLogin).toLocaleDateString('ja-JP')}
);
}
export const getServerSideProps: GetServerSideProps = async (context) => {
const { userId } = context.params as Record;
const session = await getSessionFromContext(context); // 認証情報取得
// 認証チェック
if (!session || session.user.role !== 'admin') {
return {
redirect: {
destination: '/login',
permanent: false,
},
};
}
try {
const user = await fetchUserById(userId);
if (!user) {
return { notFound: true };
}
return {
props: { user },
};
} catch (error) {
console.error('Error fetching user:', error);
return {
notFound: true,
};
}
};
async function getSessionFromContext(context: any) {
// 実装例
return null;
}
async function fetchUserById(userId: string): Promise {
// 実装例
return null;
}
4. よくある応用パターン
4.1 キャッチオールルート
すべてのパラメータパターンをキャッチしたい場合、スプレッド演算子を使用します。例えば、/docs/getting-started/installationのような複数階層のURLを処理する場合に有効です。
// pages/docs/[...slug].tsx
import { GetStaticProps, GetStaticPaths } from 'next';
interface DocPage {
title: string;
content: string;
breadcrumbs: string[];
}
interface Props {
doc: DocPage;
}
export default function DocPage({ doc }: Props) {
return (
{doc.title}
);
}
export const getStaticPaths: GetStaticPaths = async () => {
const docs = await fetchAllDocPaths();
const paths = docs.map((doc) => ({
params: {
slug: doc.slug.split('/'),
},
}));
return {
paths,
fallback: 'blocking',
};
};
export const getStaticProps: GetStaticProps = async ({ params }) => {
const slug = (params?.slug as string[])?.join('/');
try {
const doc = await fetchDocContent(slug);
if (!doc) {
return { notFound: true };
}
return {
props: { doc },
revalidate: 3600,
};
} catch (error) {
return {
revalidate: 300,
};
}
};
async function fetchAllDocPaths() {
return [];
}
async function fetchDocContent(slug: string): Promise {
return null;
}
4.2 オプショナルなキャッチオールルート
ルートパラメータを完全にオプショナルにしたい場合は、ダブルブラケットを使います。
// pages/search/[[...query]].tsx
import { GetStaticProps } from 'next';
interface SearchResult {
id: string;
title: string;
relevance: number;
}
interface Props {
results: SearchResult[];
query: string;
}
export default function SearchPage({ results, query }: Props) {
return (
検索結果
{query && 「{query}」の検索結果: {results.length}件
}
{results.length === 0 ? (
結果がありません
) : (
{results.map((result) => (
-
{result.title}
関連度: {Math.round(result.relevance * 100)}%
))}
)}
);
}
export const getStaticProps: GetStaticProps = async ({ params }) => {
const queryArray = params?.query as string[] | undefined;
const query = queryArray?.join(' ') || '';
if (!query) {
return {
props: { results: [], query: '' },
revalidate: 86400,
};
}
try {
const results = await performSearch(query);
return {
props: { results, query },
revalidate: 3600,
};
} catch (error) {
return {
props: { results: [], query },
revalidate: 300,
};
}
};
async function performSearch(query: string): Promise {
return [];
}
4.3 API ルートとの組み合わせ
Dynamic Routesと API ルートを組み合わせることで、より柔軟なデータ取得が可能になります。特にISR(Incremental Static Regeneration)時にAPIを呼び出す場合に有効です。
// pages/api/products/[id].ts
import type { NextApiRequest, NextApiResponse } from 'next';
type ResponseData = {
success: boolean;
data?: any;
error?: string;
};
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const { id } = req.query;
if (req.method === 'GET') {
try {
const product = await fetchProductFromDatabase(String(id));
if (!product) {
return res.status(404).json({ success: false, error: 'Product not found' });
}
return res.status(200).json({ success: true, data: product });
} catch (error) {
return res.status(500).json({ success: false, error: 'Internal server error' });
}
}
if (req.method === 'PUT') {
// 認証チェック
const token = req.headers.authorization?.split(' ')[1];
if (!token || !verifyToken(token)) {
return res.status(401).json({ success: false, error: 'Unauthorized' });
}
try {
const updatedProduct = await updateProductInDatabase(String(id), req.body);
return res.status(200).json({ success: true, data: updatedProduct });
} catch (error) {
return res.status(500).json({ success: false, error: 'Internal server error' });
}
}
res.setHeader('Allow', ['GET', 'PUT']);
res.status(405).json({ success: false, error: 'Method not allowed' });
}
function verifyToken(token: string): boolean {
// トークン検証ロジック
return true;
}
async function fetchProductFromDatabase(id: string) {
return null;
}
async function updateProductInDatabase(id: string, data: any) {
return null;
}
5. 注意点と最適化のコツ
5.1 ISR(増分静的再生成)の適切な設定
revalidate値を設定することで、バックグラウンドで古いページを再生成できます。ただし、トラフィック量に応じた適切な値の設定が重要です。
- 頻繁に更新されるコンテンツ:60~300秒
- 定期的に更新されるコンテンツ:3600~86400秒
- ほとんど変わらないコンテンツ:604800秒以上
ただし、eコマースの在庫情報のように非常に頻繁に変わるデータはSSRやAPIの直接呼び出しを検討してください。
5.2 fallback戦略の選択
Dynamic Routesのfallback設定には3つの選択肢があります:
- fallback: false:事前生成されたパスのみ提供。存在しないパスは404を返す
- fallback: ‘blocking’:未生成パスはサーバーサイドで生成してから返す。ユーザーを待たせるが、フラッシュなし
- fallback: true:未生成パスは即座に返し、バックグラウンドで生成。スケルトンUIの実装が必要
5.3 データベースクエリの最適化
大規模なサイトでは、getStaticPropsで実行されるデータベースクエリが大量になり、ビルド時間が伸びる可能性があります。対策としては:
- APIを経由してデータを取得し、キャッシュを活用
- 人気ページのみ事前生成し、その他はfallback: ‘blocking’
- データベースのインデックスを最適化
5.4 SEO対策
Dynamic Routesで生成されるページでは、メタタグの適切な設定が重要です。
import Head from 'next/head';
export default function ProductPage({ product }: Props) {
return (
<>
{product.name} | 商品名
{/* ページコンテンツ */}
>
);
}
5.5 エラーハンドリング
予期しないエラーが発生する可能性があるため、適切なエラーハンドリングが必須です。
export const getStaticProps: GetStaticProps = async ({ params }) => {
try {
const data = await fetchData(params?.id as string);
if (!data) {
return {
notFound: true,
revalidate: 300, // 300秒後に再試行
};
}
return {
props: { data },
revalidate: 3600,
};
} catch (error) {
console.error('Error in getStaticProps:', error);
// エラーログをサービスに送信(例:Sentry)
captureException(error);
// キャッシュされた古いデータを返すか、簡易版ページを返す
return {
revalidate: 60, // 1分後に再試行
};
}
}
6. 実務でよくあるトラブルと解決方法
6.1 ビルド時間の増加
問題:Dynamic Routesが増えると、ビルド時間が指数関数的に増加する
解決方法:
- person popular itemsのみ事前生成
- остальは fallback: ‘blocking’ で対応
- getStaticPathsで並列処理を活用
export const getStaticPaths: GetStaticPaths = async () => {\n // 人気商品のみ事前生成(例:トップ100)\n const paths = await Promise.all(\n Array.from({ length: 100 }).map((_, i) => \n fetchProductPaths(i * 10, (i + 1) * 10)\n )\n ).then(results => results.flat());\n\n return {\n paths,\n fallback: 'blocking', // その他はオンデマンド\n };\n};
6.2 ホットスポットと冷たいパス
問題:人気ページと不人気ページでレスポンス時間が大きく異なる
解決方法:
- 人気ページはキャッシュされているため高速
- 初回訪問ページはfallback: ‘blocking’で若干遅い
- Vercelなどのプラットフォームではオートスケーリングで対応
6.3 動的なパラメータの変更
問題:URLパラメータの形式を変更した場合、既存ページにアクセスできなくなる
解決方法:リダイレクトを実装して後方互換性を保つ
export const getStaticProps: GetStaticProps = async ({ params }) => {\n const oldId = params?.id as string;\n \n // 古いIDフォーマットから新しいIDフォーマットに変換\n const newId = convertOldIdToNewId(oldId);\n \n if (newId !== oldId) {\n return {\n redirect: {\n destination: `/products/${newId}`,\n permanent: true, // 301リダイレクト\n },\n };\n }\n \n // 通常の処理\n};
7. パフォーマンス測定
Dynamic Routesの性能を測定するために、Web Vitalsを監視することが重要です。

